import * as CFI from "./tools/epubcfi.js";
import { TOCProgress, SectionProgress } from "./ui/progress.js";
import { Overlayer } from "./ui/overlayer.js";
import { textWalker } from "./tools/text-walker.js";

const SEARCH_PREFIX = "foliate-search:";

const isZip = async (file) => {
  const arr = new Uint8Array(await file.slice(0, 4).arrayBuffer());
  return (
    arr[0] === 0x50 && arr[1] === 0x4b && arr[2] === 0x03 && arr[3] === 0x04
  );
};

const isPDF = async (file) => {
  const arr = new Uint8Array(await file.slice(0, 5).arrayBuffer());
  return (
    arr[0] === 0x25 &&
    arr[1] === 0x50 &&
    arr[2] === 0x44 &&
    arr[3] === 0x46 &&
    arr[4] === 0x2d
  );
};

const isCBZ = ({ name, type }) =>
  type === "application/vnd.comicbook+zip" || name.endsWith(".cbz");

const isFB2 = ({ name, type }) =>
  type === "application/x-fictionbook+xml" || name.endsWith(".fb2");

const isFBZ = ({ name, type }) =>
  type === "application/x-zip-compressed-fb2" ||
  name.endsWith(".fb2.zip") ||
  name.endsWith(".fbz");

const makeZipLoader = async (file) => {
  const { configure, ZipReader, BlobReader, TextWriter, BlobWriter } =
    await import("./vendor/zip.js");
  configure({ useWebWorkers: false });
  const reader = new ZipReader(new BlobReader(file));
  const entries = await reader.getEntries();
  const map = new Map(entries.map((entry) => [entry.filename, entry]));
  const load =
    (f) =>
    (name, ...args) =>
      map.has(name) ? f(map.get(name), ...args) : null;
  const loadText = load((entry) => entry.getData(new TextWriter()));
  const loadBlob = load((entry, type) => entry.getData(new BlobWriter(type)));
  const getSize = (name) => map.get(name)?.uncompressedSize ?? 0;
  return { entries, loadText, loadBlob, getSize };
};

const getFileEntries = async (entry) =>
  entry.isFile
    ? entry
    : (
        await Promise.all(
          Array.from(
            await new Promise((resolve, reject) =>
              entry.createReader().readEntries(
                (entries) => resolve(entries),
                (error) => reject(error)
              )
            ),
            getFileEntries
          )
        )
      ).flat();

const makeDirectoryLoader = async (entry) => {
  const entries = await getFileEntries(entry);
  const files = await Promise.all(
    entries.map(
      (entry) =>
        new Promise((resolve, reject) =>
          entry.file(
            (file) => resolve([file, entry.fullPath]),
            (error) => reject(error)
          )
        )
    )
  );
  const map = new Map(
    files.map(([file, path]) => [path.replace(entry.fullPath + "/", ""), file])
  );
  const decoder = new TextDecoder();
  const decode = (x) => (x ? decoder.decode(x) : null);
  const getBuffer = (name) => map.get(name)?.arrayBuffer() ?? null;
  const loadText = async (name) => decode(await getBuffer(name));
  const loadBlob = (name) => map.get(name);
  const getSize = (name) => map.get(name)?.size ?? 0;
  return { loadText, loadBlob, getSize };
};

export class ResponseError extends Error {}
export class NotFoundError extends Error {}
export class UnsupportedTypeError extends Error {}

const fetchFile = async (url) => {
  const res = await fetch(url);
  if (!res.ok)
    throw new ResponseError(`${res.status} ${res.statusText}`, { cause: res });
  return new File([await res.blob()], new URL(res.url).pathname);
};

const img2str = (img) => {
  return new Promise(function (resolve, reject) {
    const fileReader = new FileReader();
    fileReader.readAsDataURL(img);
    fileReader.onload = () => {
      const str = fileReader.result;
      resolve(str);
    };
  });
};

export const makeBook = async (file) => {
  if (typeof file === "string") file = await fetchFile(file);
  let book;
  if (file.isDirectory) {
    const loader = await makeDirectoryLoader(file);
    const { EPUB } = await import("./tools/epub.js");
    book = await new EPUB(loader).init();
  } else if (!file.size) throw new NotFoundError("File not found");
  else if (await isZip(file)) {
    const loader = await makeZipLoader(file);
    if (isCBZ(file)) {
      const { makeComicBook } = await import("./tools/comic-book.js");
      book = makeComicBook(loader, file);
    } else if (isFBZ(file)) {
      const { makeFB2 } = await import("./tools/fb2.js");
      const { entries } = loader;
      const entry = entries.find((entry) => entry.filename.endsWith(".fb2"));
      const blob = await loader.loadBlob((entry ?? entries[0]).filename);
      book = await makeFB2(blob);
    } else {
      const { EPUB } = await import("./tools/epub.js");
      book = await new EPUB(loader).init();
    }
  }
  // else if (await isPDF(file)) {
  //     const { makePDF } = await import('./tools/pdf.js')
  //     book = await makePDF(file)
  // }
  else {
    const { isMOBI, MOBI } = await import("./tools/mobi.js");
    if (await isMOBI(file)) {
      const fflate = await import("./vendor/fflate.js");
      book = await new MOBI({ unzlib: fflate.unzlibSync }).open(file);
    } else if (isFB2(file)) {
      const { makeFB2 } = await import("./tools/fb2.js");
      book = await makeFB2(file);
    }
  }
  const blobCover = await book.getCover();
  if (blobCover) {
    const strCover = await img2str(blobCover);
    book.metadata.cover = strCover;
  }
  if (!book) throw new UnsupportedTypeError("File type not supported");
  return book;
};

class CursorAutohider {
  #timeout;
  #el;
  #check;
  #state;
  constructor(el, check, state = {}) {
    this.#el = el;
    this.#check = check;
    this.#state = state;
    if (this.#state.hidden) this.hide();
    this.#el.addEventListener(
      "mousemove",
      ({ screenX, screenY }) => {
        // check if it actually moved
        if (screenX === this.#state.x && screenY === this.#state.y) return;
        (this.#state.x = screenX), (this.#state.y = screenY);
        this.show();
        if (this.#timeout) clearTimeout(this.#timeout);
        if (check()) this.#timeout = setTimeout(this.hide.bind(this), 1000);
      },
      false
    );
  }
  cloneFor(el) {
    return new CursorAutohider(el, this.#check, this.#state);
  }
  hide() {
    this.#el.style.cursor = "none";
    this.#state.hidden = true;
  }
  show() {
    this.#el.style.removeProperty("cursor");
    this.#state.hidden = false;
  }
}

class History extends EventTarget {
  #arr = [];
  #index = -1;
  pushState(x) {
    const last = this.#arr[this.#index];
    if (last === x || (last?.fraction && last.fraction === x.fraction)) return;
    this.#arr[++this.#index] = x;
    this.#arr.length = this.#index + 1;
    this.dispatchEvent(new Event("index-change"));
  }
  replaceState(x) {
    const index = this.#index;
    this.#arr[index] = x;
  }
  back() {
    const index = this.#index;
    if (index <= 0) return;
    const detail = { state: this.#arr[index - 1] };
    this.#index = index - 1;
    this.dispatchEvent(new CustomEvent("popstate", { detail }));
    this.dispatchEvent(new Event("index-change"));
  }
  forward() {
    const index = this.#index;
    if (index >= this.#arr.length - 1) return;
    const detail = { state: this.#arr[index + 1] };
    this.#index = index + 1;
    this.dispatchEvent(new CustomEvent("popstate", { detail }));
    this.dispatchEvent(new Event("index-change"));
  }
  get canGoBack() {
    return this.#index > 0;
  }
  get canGoForward() {
    return this.#index < this.#arr.length - 1;
  }
  clear() {
    this.#arr = [];
    this.#index = -1;
  }
}

const languageInfo = (lang) => {
  if (!lang) return {};
  try {
    const canonical = Intl.getCanonicalLocales(lang)[0];
    const locale = new Intl.Locale(canonical);
    const isCJK = ["zh", "ja", "kr"].includes(locale.language);
    const direction = (locale.getTextInfo?.() ?? locale.textInfo)?.direction;
    return { canonical, locale, isCJK, direction };
  } catch (e) {
    console.warn(e);
    return {};
  }
};

export class View extends HTMLElement {
  #root = this.attachShadow({ mode: "closed" });
  #sectionProgress;
  #tocProgress;
  #pageProgress;
  #searchResults = new Map();
  #cursorAutohider = new CursorAutohider(this, () =>
    this.hasAttribute("autohide-cursor")
  );
  isFixedLayout = false;
  lastLocation;
  history = new History();
  constructor() {
    super();
    this.history.addEventListener("popstate", ({ detail }) => {
      const resolved = this.resolveNavigation(detail.state);
      this.renderer.goTo(resolved);
    });
  }
  async open(book) {
    if (
      typeof book === "string" ||
      typeof book.arrayBuffer === "function" ||
      book.isDirectory
    )
      book = await makeBook(book);
    this.book = book;
    this.language = languageInfo(book.metadata?.language);

    if (book.splitTOCHref && book.getTOCFragment) {
      const ids = book.sections.map((s) => s.id);
      this.#sectionProgress = new SectionProgress(book.sections, 1500, 1600);
      const splitHref = book.splitTOCHref.bind(book);
      const getFragment = book.getTOCFragment.bind(book);
      this.#tocProgress = new TOCProgress();
      await this.#tocProgress.init({
        toc: book.toc ?? [],
        ids,
        splitHref,
        getFragment,
      });
      this.#pageProgress = new TOCProgress();
      await this.#pageProgress.init({
        toc: book.pageList ?? [],
        ids,
        splitHref,
        getFragment,
      });
    }

    this.isFixedLayout = this.book.rendition?.layout === "pre-paginated";
    if (this.isFixedLayout) {
      await import("./ui/fixed-layout.js");
      this.renderer = document.createElement("foliate-fxl");
    } else {
      await import("./ui/paginator.js");
      this.renderer = document.createElement("foliate-paginator");
    }
    this.renderer.setAttribute("exportparts", "head,foot,filter");
    this.renderer.addEventListener("load", (e) => this.#onLoad(e.detail));
    this.renderer.addEventListener("relocate", (e) =>
      this.#onRelocate(e.detail)
    );
    this.renderer.addEventListener("create-overlayer", (e) =>
      e.detail.attach(this.#createOverlayer(e.detail))
    );
    this.renderer.open(book);
    this.#root.append(this.renderer);

    if (book.sections.some((section) => section.mediaOverlay)) {
      const activeClass = book.media.activeClass;
      const playbackActiveClass = book.media.playbackActiveClass;
      this.mediaOverlay = book.getMediaOverlay();
      let lastActive;
      this.mediaOverlay.addEventListener("highlight", (e) => {
        const resolved = this.resolveNavigation(e.detail.text);
        this.renderer.goTo(resolved).then(() => {
          const { doc } = this.renderer
            .getContents()
            .find((x) => (x.index = resolved.index));
          const el = resolved.anchor(doc);
          el.classList.add(activeClass);
          if (playbackActiveClass)
            el.ownerDocument.documentElement.classList.add(playbackActiveClass);
          lastActive = new WeakRef(el);
        });
      });
      this.mediaOverlay.addEventListener("unhighlight", () => {
        const el = lastActive?.deref();
        if (el) {
          el.classList.remove(activeClass);
          if (playbackActiveClass)
            el.ownerDocument.documentElement.classList.remove(
              playbackActiveClass
            );
        }
      });
    }
  }
  close() {
    this.renderer?.destroy();
    this.renderer?.remove();
    this.#sectionProgress = null;
    this.#tocProgress = null;
    this.#pageProgress = null;
    this.#searchResults = new Map();
    this.lastLocation = null;
    this.history.clear();
    this.tts = null;
    this.mediaOverlay = null;
  }
  goToTextStart() {
    return this.goTo(
      this.book.landmarks?.find(
        (m) => m.type.includes("bodymatter") || m.type.includes("text")
      )?.href ?? this.book.sections.findIndex((s) => s.linear !== "no")
    );
  }
  async init({ lastLocation, showTextStart }) {
    const resolved = lastLocation ? this.resolveNavigation(lastLocation) : null;
    if (resolved) {
      await this.renderer.goTo(resolved);
      this.history.pushState(lastLocation);
    } else if (showTextStart) await this.goToTextStart();
    else {
      this.history.pushState(0);
      await this.next();
    }
  }
  #emit(name, detail, cancelable) {
    return this.dispatchEvent(new CustomEvent(name, { detail, cancelable }));
  }
  #onRelocate({ reason, range, index, fraction, size }) {
    const progress =
      this.#sectionProgress?.getProgress(index, fraction, size) ?? {};
    const tocItem = this.#tocProgress?.getProgress(index, range);
    const pageItem = this.#pageProgress?.getProgress(index, range);
    const cfi = this.getCFI(index, range);
    this.lastLocation = { ...progress, tocItem, pageItem, cfi, range };
    if (reason === "snap" || reason === "page" || reason === "scroll")
      this.history.replaceState(cfi);
    this.#emit("relocate", this.lastLocation);
  }
  #onLoad({ doc, index }) {
    // set language and dir if not already set
    doc.documentElement.lang ||= this.language.canonical ?? "";
    if (!this.language.isCJK)
      doc.documentElement.dir ||= this.language.direction ?? "";

    this.#handleLinks(doc, index);
    this.#cursorAutohider.cloneFor(doc.documentElement);

    this.#emit("load", { doc, index });
  }
  #handleLinks(doc, index) {
    const { book } = this;
    const section = book.sections[index];
    doc.addEventListener("click", (e) => {
      const a = e.target.closest("a[href]");
      if (!a) return;
      e.preventDefault();
      const href_ = a.getAttribute("href");
      const href = section?.resolveHref?.(href_) ?? href_;
      if (book?.isExternal?.(href))
        Promise.resolve(this.#emit("external-link", { a, href }, true))
          .then((x) => (x ? globalThis.open(href, "_blank") : null))
          .catch((e) => console.error(e));
      else
        Promise.resolve(this.#emit("link", { a, href }, true))
          .then((x) => (x ? this.goTo(href) : null))
          .catch((e) => console.error(e));
    });
  }
  async addAnnotation(annotation, remove) {
    const { value } = annotation;
    if (value.startsWith(SEARCH_PREFIX)) {
      const cfi = value.replace(SEARCH_PREFIX, "");
      const { index, anchor } = await this.resolveNavigation(cfi);
      const obj = this.#getOverlayer(index);
      if (obj) {
        const { overlayer, doc } = obj;
        if (remove) {
          overlayer.remove(value);
          return;
        }
        const range = doc ? anchor(doc) : anchor;
        overlayer.add(value, range, Overlayer.outline);
      }
      return;
    }
    const { index, anchor } = await this.resolveNavigation(value);
    const obj = this.#getOverlayer(index);
    if (obj) {
      const { overlayer, doc } = obj;
      overlayer.remove(value);
      if (!remove) {
        const range = doc ? anchor(doc) : anchor;
        const draw = (func, opts) => overlayer.add(value, range, func, opts);
        this.#emit("draw-annotation", { draw, annotation, doc, range });
      }
    }
    const label = this.#tocProgress.getProgress(index)?.label ?? "";
    return { index, label };
  }
  deleteAnnotation(annotation) {
    return this.addAnnotation(annotation, true);
  }
  #getOverlayer(index) {
    return this.renderer
      .getContents()
      .find((x) => x.index === index && x.overlayer);
  }
  #createOverlayer({ doc, index }) {
    const overlayer = new Overlayer();
    doc.addEventListener(
      "click",
      (e) => {
        const [value, range] = overlayer.hitTest(e);
        if (value && !value.startsWith(SEARCH_PREFIX)) {
          this.#emit("show-annotation", { value, index, range });
        }
      },
      false
    );

    const list = this.#searchResults.get(index);
    if (list) for (const item of list) this.addAnnotation(item);

    this.#emit("create-overlay", { index });
    return overlayer;
  }
  async showAnnotation(annotation) {
    const { value } = annotation;
    const resolved = await this.goTo(value);
    if (resolved) {
      const { index, anchor } = resolved;
      const { doc } = this.#getOverlayer(index);
      const range = anchor(doc);
      this.#emit("show-annotation", { value, index, range });
    }
  }
  getCFI(index, range) {
    const baseCFI = this.book.sections[index].cfi ?? CFI.fake.fromIndex(index);
    if (!range) return baseCFI;
    return CFI.joinIndir(baseCFI, CFI.fromRange(range));
  }
  resolveCFI(cfi) {
    if (this.book.resolveCFI) return this.book.resolveCFI(cfi);
    else {
      const parts = CFI.parse(cfi);
      const index = CFI.fake.toIndex((parts.parent ?? parts).shift());
      const anchor = (doc) => CFI.toRange(doc, parts);
      return { index, anchor };
    }
  }
  resolveNavigation(target) {
    try {
      if (typeof target === "number") return { index: target };
      if (typeof target.fraction === "number") {
        const [index, anchor] = this.#sectionProgress.getSection(
          target.fraction
        );
        return { index, anchor };
      }
      if (CFI.isCFI.test(target)) return this.resolveCFI(target);
      return this.book.resolveHref(target);
    } catch (e) {
      console.error(e);
      console.error(`Could not resolve target ${target}`);
    }
  }
  async goTo(target) {
    console.log("view - goTo", target);
    const resolved = this.resolveNavigation(target);
    try {
      console.log("resolved", resolved);
      await this.renderer.goTo(resolved);
      this.history.pushState(target);
      return resolved;
    } catch (e) {
      console.error(e);
      console.error(`Could not go to ${target}`);
    }
  }
  async goToFraction(frac) {
    const [index, anchor] = this.#sectionProgress.getSection(frac);
    await this.renderer.goTo({ index, anchor });
    this.history.pushState({ fraction: frac });
  }
  async select(target) {
    try {
      const obj = await this.resolveNavigation(target);
      await this.renderer.goTo({ ...obj, select: true });
      this.history.pushState(target);
    } catch (e) {
      console.error(e);
      console.error(`Could not go to ${target}`);
    }
  }
  deselect() {
    for (const { doc } of this.renderer.getContents())
      doc.defaultView.getSelection().removeAllRanges();
  }
  getSectionFractions() {
    return (this.#sectionProgress?.sectionFractions ?? []).map(
      (x) => x + Number.EPSILON
    );
  }
  getProgressOf(index, range) {
    const tocItem = this.#tocProgress?.getProgress(index, range);
    const pageItem = this.#pageProgress?.getProgress(index, range);
    return { tocItem, pageItem };
  }
  async getTOCItemOf(target) {
    try {
      const { index, anchor } = await this.resolveNavigation(target);
      const doc = await this.book.sections[index].createDocument();
      const frag = anchor(doc);
      const isRange = frag instanceof Range;
      const range = isRange ? frag : doc.createRange();
      if (!isRange) range.selectNodeContents(frag);
      return this.#tocProgress.getProgress(index, range);
    } catch (e) {
      console.error(e);
      console.error(`Could not get ${target}`);
    }
  }
  async prev(distance) {
    await this.renderer.prev(distance);
  }
  async next(distance) {
    await this.renderer.next(distance);
  }
  goLeft() {
    return this.book.dir === "rtl" ? this.next() : this.prev();
  }
  goRight() {
    return this.book.dir === "rtl" ? this.prev() : this.next();
  }
  async *#searchSection(matcher, query, index) {
    const doc = await this.book.sections[index].createDocument();
    for (const { range, excerpt } of matcher(doc, query))
      yield { cfi: this.getCFI(index, range), excerpt };
  }
  async *#searchBook(matcher, query) {
    const { sections } = this.book;
    for (const [index, { createDocument }] of sections.entries()) {
      if (!createDocument) continue;
      const doc = await createDocument();
      const subitems = Array.from(
        matcher(doc, query),
        ({ range, excerpt }) => ({ cfi: this.getCFI(index, range), excerpt })
      );
      const progress = (index + 1) / sections.length;
      yield { progress };
      if (subitems.length) yield { index, subitems };
    }
  }
  async *search(opts) {
    this.clearSearch();
    const { searchMatcher } = await import("./tools/search.js");
    const { query, index } = opts;
    const matcher = searchMatcher(textWalker, {
      defaultLocale: this.language,
      ...opts,
    });
    const iter =
      index != null
        ? this.#searchSection(matcher, query, index)
        : this.#searchBook(matcher, query);

    const list = [];
    this.#searchResults.set(index, list);

    for await (const result of iter) {
      if (result.subitems) {
        const list = result.subitems.map(({ cfi }) => ({
          value: SEARCH_PREFIX + cfi,
        }));
        this.#searchResults.set(result.index, list);
        for (const item of list) this.addAnnotation(item);
        yield {
          label: this.#tocProgress.getProgress(result.index)?.label ?? "",
          subitems: result.subitems,
        };
      } else {
        if (result.cfi) {
          const item = { value: SEARCH_PREFIX + result.cfi };
          list.push(item);
          this.addAnnotation(item);
        }
        yield result;
      }
    }
    yield "done";
  }
  clearSearch() {
    for (const list of this.#searchResults.values())
      for (const item of list) this.deleteAnnotation(item);
    this.#searchResults.clear();
  }
  async initTTS(granularity = "word") {
    const doc = this.renderer.getContents()[0].doc;
    if (this.tts && this.tts.doc === doc) return;
    const { TTS } = await import("./tools/tts.js");
    this.tts = new TTS(
      doc,
      textWalker,
      (range) => this.renderer.scrollToAnchor(range, true),
      granularity
    );
  }
  startMediaOverlay() {
    const { index } = this.renderer.getContents()[0];
    return this.mediaOverlay.start(index);
  }
}

customElements.define("foliate-view", View);
